Skip to content

feat: add dynamic sitemap route map#32

Closed
theDevCx wants to merge 1 commit into
ubiquity:mainfrom
theDevCx:bounty/dynamic-sitemap
Closed

feat: add dynamic sitemap route map#32
theDevCx wants to merge 1 commit into
ubiquity:mainfrom
theDevCx:bounty/dynamic-sitemap

Conversation

@theDevCx

Copy link
Copy Markdown

Summary

  • Adds /sitemap.xml for public app/plugin URLs.
  • Adds /routes.json and /sitemap.json for machine-readable infrastructure maps.
  • Builds the map dynamically from active ubiquity GitHub repos:
    • *.ubq.fi repos become app routes.
    • ubiquity-os-* and plugin-* repos become plugin routes.
  • Preserves router revision headers on map responses.

Verification

  • npm run type-check
  • npx bun test

Closes #2

@ubiquity-os

ubiquity-os Bot commented Jun 13, 2026

Copy link
Copy Markdown

Warning

@theDevCx this pull request is linked to an issue that is already assigned to another user. Please link it to an open issue assigned to you or to an unassigned open issue.

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements dynamic sitemap and route-map generation for apps and plugins. A new src/sitemap.ts module exports handleRouteMap() to respond to /sitemap.xml, /routes.json, and /sitemap.json requests. It fetches organization repositories from GitHub's API (paginated, excluding archived/disabled repos), derives app entries from ubq.fi and *.ubq.fi repos and plugin entries from ubiquity-os-* and plugin-* repos, then returns the results as either XML or JSON with short cache headers. The src/worker.ts integration adds an early routing check before existing handlers, and comprehensive Bun tests verify both data collection and HTTP response behavior.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding dynamic sitemap and route map functionality.
Description check ✅ Passed The description clearly explains the feature, implementation approach, and verification steps undertaken.
Linked Issues check ✅ Passed The PR implements all requirements from #2: dynamic generation of XML/JSON maps for apps and plugins from GitHub repos.
Out of Scope Changes check ✅ Passed All changes directly support the linked issue objectives; no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/worker.ts (1)

65-68: 💤 Low value

Optional: Route-map responses bypass logging.

The early return means /sitemap.xml, /routes.json, and /sitemap.json never reach the sampling/logging logic used for health, RPC, and route handlers. This is likely intentional for low-traffic public endpoints, but consider adding minimal logging if observability is needed later.

tests/sitemap.test.ts (1)

14-14: ⚡ Quick win

Improve mock robustness.

The fetch mock returns the same response for all URLs and doesn't restore globalThis.fetch after the test. Consider:

  • Verifying the GitHub API URL in the mock
  • Restoring the original fetch in an afterEach or try-finally block

The current mock works because the data is small (< 100 items, so pagination stops), but it's fragile.

🔧 Suggested mock improvement
  test('builds JSON route map from active GitHub repos', async () => {
-    globalThis.fetch = (async () => Response.json(githubResponse)) as unknown as typeof fetch
+    const originalFetch = globalThis.fetch
+    globalThis.fetch = (async (url: string) => {
+      if (url.includes('api.github.com/orgs/ubiquity/repos')) {
+        return Response.json(githubResponse)
+      }
+      return originalFetch(url)
+    }) as unknown as typeof fetch

    const entries = await buildRouteMap('https://ubq.fi')

    expect(entries).toEqual([
      // ... expectations
    ])
+
+    globalThis.fetch = originalFetch
  })

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e9b2104d-4f82-4bfd-bfa8-cf27f22b84f6

📥 Commits

Reviewing files that changed from the base of the PR and between 91bebfb and 218c27c.

📒 Files selected for processing (3)
  • src/sitemap.ts
  • src/worker.ts
  • tests/sitemap.test.ts

Comment thread src/sitemap.ts
Comment on lines +23 to +35
export async function handleRouteMap(requestUrl: URL): Promise<Response | null> {
if (requestUrl.pathname === '/sitemap.xml') {
const entries = await buildRouteMap(requestUrl.origin)
return new Response(toSitemapXml(entries), {
headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' },
})
}

if (requestUrl.pathname === '/routes.json' || requestUrl.pathname === '/sitemap.json') {
const entries = await buildRouteMap(requestUrl.origin)
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), {
headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' },
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant parts of the file
FILE="src/sitemap.ts"
if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi

echo "=== ${FILE} (around handleRouteMap) ==="
sed -n '1,220p' "$FILE" | nl -ba | sed -n '1,220p' | sed -n '1,220p'

echo "=== ${FILE} (search for fetchOrgRepos / buildRouteMap / originForHost / escapeXml) ==="
rg -n "fetchOrgRepos|buildRouteMap|originForHost|escapeXml|toSitemapXml|handleRouteMap" "$FILE" || true

echo "=== ${FILE} (exact definitions) ==="
# Print full function bodies (best-effort by naive line ranges)
# If functions are large, we at least capture their internals around matches.
for pat in "export async function handleRouteMap" "async function buildRouteMap" "async function fetchOrgRepos" "function originForHost" "function escapeXml"; do
  line=$(rg -n "$pat" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -n "${line:-}" ]; then
    start=$((line-5))
    end=$((line+220))
    echo "--- $pat at line $line (showing $start-$end) ---"
    sed -n "${start},${end}p" "$FILE" | nl -ba | sed -n "1,260p"
  fi
done

Repository: ubiquity/ubq.fi-router

Length of output: 175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/sitemap.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }

echo "=== Lines around handleRouteMap ==="
line=$(rg -n "export async function handleRouteMap" "$FILE" | head -n1 | cut -d: -f1)
echo "handleRouteMap at: $line"
start=$((line-25)); end=$((line+120))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== Function bodies / key helpers ==="
for pat in "buildRouteMap" "fetchOrgRepos" "originForHost" "escapeXml" "toSitemapXml"; do
  l=$(rg -n "${pat}" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -n "${l:-}" ]; then
    echo "--- ${pat} at ${l} ---"
    s=$((l-20)); e=$((l+180))
    sed -n "${s},${e}p" "$FILE" | cat -n | head -n 260
  fi
done

Repository: ubiquity/ubq.fi-router

Length of output: 1965


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/sitemap.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }

echo "=== handleRouteMap block ==="
line=$(rg -n "export async function handleRouteMap" "$FILE" | head -n1 | cut -d: -f1)
echo "handleRouteMap at: $line"
start=$((line-20)); [ $start -lt 1 ] && start=1
end=$((line+90))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== buildRouteMap block ==="
line=$(rg -n "async function buildRouteMap" "$FILE" | head -n1 | cut -d: -f1)
echo "buildRouteMap at: $line"
start=$((line-20)); [ $start -lt 1 ] && start=1
end=$((line+140))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== fetchOrgRepos block ==="
line=$(rg -n "async function fetchOrgRepos" "$FILE" | head -n1 | cut -d: -f1)
echo "fetchOrgRepos at: $line"
start=$((line-20)); [ $start -lt 1 ] && start=1
end=$((line+180))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== originForHost + escapeXml blocks ==="
for pat in "function originForHost" "function escapeXml"; do
  line=$(rg -n "$pat" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -z "${line:-}" ]; then echo "Not found: $pat"; continue; fi
  echo "$pat at: $line"
  start=$((line-10)); [ $start -lt 1 ] && start=1
  end=$((line+120))
  sed -n "${start},${end}p" "$FILE" | cat -n
done

Repository: ubiquity/ubq.fi-router

Length of output: 12674


Fail closed on GitHub API errors; dedupe plugin entries that normalize to the same host

fetchOrgRepos() stops pagination on !response.ok and returns partial/empty repos, and handleRouteMap() doesn’t handle failures—so /sitemap.xml and the JSON endpoints can silently return incomplete data with HTTP 200.

Proposed fix
 export async function handleRouteMap(requestUrl: URL): Promise<Response | null> {
+  const build = async () => {
+    const entries = await buildRouteMap(requestUrl.origin)
+    return entries
+  }
+
   if (requestUrl.pathname === '/sitemap.xml') {
-    const entries = await buildRouteMap(requestUrl.origin)
-    return new Response(toSitemapXml(entries), {
-      headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' },
-    })
+    try {
+      const entries = await build()
+      return new Response(toSitemapXml(entries), {
+        headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' },
+      })
+    } catch {
+      return new Response('Failed to build sitemap', { status: 502 })
+    }
   }
 
   if (requestUrl.pathname === '/routes.json' || requestUrl.pathname === '/sitemap.json') {
-    const entries = await buildRouteMap(requestUrl.origin)
-    return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), {
-      headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' },
-    })
+    try {
+      const entries = await build()
+      return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), {
+        headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' },
+      })
+    } catch {
+      return new Response(JSON.stringify({ error: 'Failed to build route map' }), {
+        status: 502,
+        headers: { 'Content-Type': 'application/json; charset=utf-8' },
+      })
+    }
   }
 async function fetchOrgRepos(): Promise<GitHubRepo[]> {
   const repos: GitHubRepo[] = []
   for (let page = 1; page <= 10; page++) {
     const response = await fetch(`${GITHUB_API_BASE}/orgs/${ORG}/repos?per_page=100&page=${page}`, {
@@
-    if (!response.ok) break
+    if (!response.ok) {
+      throw new Error(`GitHub API failed: ${response.status}`)
+    }

Minor: buildRouteMap() can emit duplicate plugin entries when both ubiquity-os-<x> and plugin-<x> exist, since both normalize to the same host (os-<x>.ubq.fi). Consider deduping by host before returning.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function handleRouteMap(requestUrl: URL): Promise<Response | null> {
if (requestUrl.pathname === '/sitemap.xml') {
const entries = await buildRouteMap(requestUrl.origin)
return new Response(toSitemapXml(entries), {
headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' },
})
}
if (requestUrl.pathname === '/routes.json' || requestUrl.pathname === '/sitemap.json') {
const entries = await buildRouteMap(requestUrl.origin)
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), {
headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' },
})
export async function handleRouteMap(requestUrl: URL): Promise<Response | null> {
const build = async () => {
const entries = await buildRouteMap(requestUrl.origin)
return entries
}
if (requestUrl.pathname === '/sitemap.xml') {
try {
const entries = await build()
return new Response(toSitemapXml(entries), {
headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' },
})
} catch {
return new Response('Failed to build sitemap', { status: 502 })
}
}
if (requestUrl.pathname === '/routes.json' || requestUrl.pathname === '/sitemap.json') {
try {
const entries = await build()
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), {
headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' },
})
} catch {
return new Response(JSON.stringify({ error: 'Failed to build route map' }), {
status: 502,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
})
}
}

Comment thread src/sitemap.ts
Comment on lines +58 to +71
.filter((repo) => repo.name.startsWith('ubiquity-os-') || repo.name.startsWith('plugin-'))
.map((repo) => {
const plugin = repo.name.replace(/^ubiquity-os-/, '').replace(/^plugin-/, '')
const host = `os-${plugin}.ubq.fi`
return {
type: 'plugin' as const,
name: repo.name,
host,
url: `${originForHost(origin, host)}/`,
upstream: `https://${plugin}-main.deno.dev/`,
}
})

return [...apps, ...plugins].sort((a, b) => a.host.localeCompare(b.host))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Deduplicate by host after app/plugin merge.

If both ubiquity-os-foo and plugin-foo exist, this produces duplicate os-foo.ubq.fi entries in sitemap/JSON.

Proposed fix
-  return [...apps, ...plugins].sort((a, b) => a.host.localeCompare(b.host))
+  const merged = [...apps, ...plugins]
+  const deduped = Array.from(new Map(merged.map((entry) => [entry.host, entry])).values())
+  return deduped.sort((a, b) => a.host.localeCompare(b.host))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.filter((repo) => repo.name.startsWith('ubiquity-os-') || repo.name.startsWith('plugin-'))
.map((repo) => {
const plugin = repo.name.replace(/^ubiquity-os-/, '').replace(/^plugin-/, '')
const host = `os-${plugin}.ubq.fi`
return {
type: 'plugin' as const,
name: repo.name,
host,
url: `${originForHost(origin, host)}/`,
upstream: `https://${plugin}-main.deno.dev/`,
}
})
return [...apps, ...plugins].sort((a, b) => a.host.localeCompare(b.host))
.filter((repo) => repo.name.startsWith('ubiquity-os-') || repo.name.startsWith('plugin-'))
.map((repo) => {
const plugin = repo.name.replace(/^ubiquity-os-/, '').replace(/^plugin-/, '')
const host = `os-${plugin}.ubq.fi`
return {
type: 'plugin' as const,
name: repo.name,
host,
url: `${originForHost(origin, host)}/`,
upstream: `https://${plugin}-main.deno.dev/`,
}
})
const merged = [...apps, ...plugins]
const deduped = Array.from(new Map(merged.map((entry) => [entry.host, entry])).values())
return deduped.sort((a, b) => a.host.localeCompare(b.host))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dynamic Sitemap (Apps & Plugins)

1 participant